查看原文
其他

golang 内存管理分析

奇伢 奇伢云存储 2022-09-09

 golang的内存分析工具怎么用?内存和回收原理,这一篇就够了

大纲

  • 1. 目录

  • 2. 由一个问题展开

  • 3. 名字说明

  • 4. 内存怎么采样?

    • 4.1 编译期间逃逸分析

    • 4.2 采样的简单实现

    • 4.3 内存采样的时机

    • 4.4 内存采样的入口

    • 4.5 内存采样的信息

    • 4.6 golang的类型反射

  • 5. 内存分配

    • 5.1 C语言你分配和释放内存怎么做?

    • 5.2 内存分配设计考虑的几个问题

    • 5.3 golang的内存分配

  • 6. 内存回收

    • 6.1 golang协程抢占执行

    • 6.2 STW是怎么回事?

    • 6.3 垃圾回收要求

    • 6.4 golang版本迭代历史

    • 6.5 GC触发条件

    • 6.6 三色定义

    • 6.7 GC流程

    • 6.8 写屏障

    • 6.9 内存可见性

    • 6.10 注意问题

1. 目录

2. 由一个问题展开

golang从语言级别,就提供了完整的采样和分析的机制。大家经常使用 pprof 分析内存占用。



但是不清楚怎么实现?不清楚怎么看指标?不清楚 flat,cum的区别?我们就从这个问题展开。

3. 名字说明

内存分析的时候,有四个输入选项:

  1. alloc_objects : 历史总分配的累计
  2. alloc_space :历史总分配累计
  3. inuse_objects:当前正在使用的对象数
    1. 堆上分配出来,业务正在使用的,也包括业务没有使用但是还没有垃圾回收掉的对象。
  4. inuse_space:当前正在使用的内存

两个输出选项:

  1. flat:平坦分配,非累加
  2. cum:累加

思考几个问题:

  1. 上面说的对象是什么概念?
  2. 经常使用内存分析,这个内存分析是否是精确的?性能消耗大不大
  3. 为啥显示的是堆栈?不是说分配的对象吗?为啥不直接显示分配的对象结构名?

4. 内存怎么采样?

4.1 编译期间逃逸分析

说明下,golang pprof是分析从堆上分配的内存。golang的内存在堆上,还是在栈上?这个不是我们决定的,就算你调用new这个关键字,也不一定是在堆上分配。

逃逸分析是golang的一个非常重要的一个点。对于内存分配,垃圾回收的设计都有非常重要的影响。

4.2 采样的简单实现

采样的实现非常简单。简单描述流程:

  1. 用一个公共变量用来记录
  2. 分配内存的时候,加alloc size,加alloc对象数
  3. 释放内存的时候,加free size,加free对象数

累计分配:就是alloc 当前在用 inuse:就是 alloc-free



4.3 内存采样的时机

采样的时机说3个点:

  1. 分配堆上内存的时候,累计分配
  2. 回收器释放堆上内存的时候,累计释放
  3. 每512KB打点采样

但是注意一点:并不是每一次分配内存都会被采样。也就是说这里其实是有个权衡的。现在是每满512KB才会采样一次。这里的考虑是性能和采样效果的权衡。因为采样是要耗费性能的,是要取堆栈的。

怎么理解?举个例子

理想情况下(不考虑其他任何影响):

那么有人会想,这样岂不是会漏掉了很多内存?统计还能用来排查问题吗?

这个是性能和效果的一个考虑,一般来讲,我们是用pprof分析内存占用的时候,在整个golang程序跑起来后,时时刻刻都在分配释放内存,每累计分配512KB,打点一次。虽然会漏掉一些内存分配释放,但是对每个结构都是公平的。如果有一个内存泄露分配行为,那么累计下来一定会被抓住的,并且是非常容易被抓住。

4.4 内存采样的入口

内存采样的入口,这个非常简单理解。肯定是一个在分配内存的函数位置,一个是释放内存的位置。这里要特意提下上下文环境。因为golang是垃圾回收类型的语言,内存分配是完全交由golang自己管理,自己不能管理内存。

两个入口函数:

  1. mProf_Malloc
  2. mProf_Free

这两个是配套使用的采样打点函数。而且一定是配套的。简单说:

  • mProf_Malloc 是由业务程序行为(赋值器)触发的,分配内存嘛。比如你new了一个对象,这个对象在堆上,那么会调用 mallocgc 分配内存,如果到了采样点,那么会调用 mProf_Malloc 采样。
  • mProf_Free 是回收器在确定并且将要回收内存的时候调用的。是垃圾回收过程的一环。并且还要注意一点,只有打过点的(mProf_Malloc计数过的对象,会有一个特殊处理),才会配套使用mProf_Free。
    • 不是说,任意给一个内存地址给你。你都知道这个是业务类型。

4.5 内存采样的信息

这里问你的是,golang采样是采样啥?类型信息?这里也说过一点,内存这里和类型系统是没啥关系的。这里采样的是分配栈,也就是分配路径。

4.5.1 flat,cum 分别是怎么来的?

看个例子:

大家可以先猜下,我们看alloc_space。这个内存会是怎么累计到的。实际统计如下:

和大家猜的一样吗?这些是怎么看。

首先说几个结论:

  1. flat统计到的,就是这个函数实际分配的。
  2. cum是累计的,包含自己分配的,也包含路过的。
  • cum和flat不相同的时候,代表这个函数除了自己分配内存,自己内部调用的别的函数也在分配内存。

重点提示:这个要理解这个,首先要知道,内存采样的是什么,内存采样的是分配栈。

解释说明

(图中140M我们当150M看哈,这里采样少了第一次,细节原因可以看代码,这里提一下,不做阐述。):

  1. main函数里,A函数调用了5次,B函数 5次,C函数5次。其中B会调用A,C会调用B。
  2. 调用一次A会分配10M内存,调用一次B会分配20M,调用一次C会分配30M。总累计分配内存是300M
  3. A函数实际调用次数是 15次;这个和flat的值是一致的:150M
    1. (A) * 5
    2. (B -> A) * 5
    3. (C -> B -> A) * 5
  4. B函数函数实际调用10次;这个和flat的值也是一致的:100M
    1. B * 5
    2. (C -> B) * 5
  5. C函数5次:这个和flat的值是一致的:50M
    1. C * 5
  6. main函数300M,也是一致的。

图示



记住一句话:采样是记录分配堆栈,而不是类型信息。

4.6 golang的类型反射

思考几个问题:

  1. 任意给一个内存地址给你,能知道这个对象类型吗?
  2. golang的反射到底是怎么回事?

先说结论:golang里面,内存块是没有携带对象类型信息的,这个跟C是一样的。但是golang又有反射,golang的反射一定要基于interface使用。这个要仔细理解下。

因为,golang里面interface的结构变量,是会记录type类型的。

反射定律一:反射一定是基于接口的。是从接口到反射类型。

反射定律二:反射一定是基于接口的。是从反射类型到接口。

还是那句话,golang的反射一定是依赖接口类型的,一定是经过接口倒腾过的。



因为当前接口这个类型对应了两个内部结构:struct ifacestruct eface,这两个结构都是会存储type类型。以后的一切都是基于这个类型的。

5. 内存分配

5.1 C语言你分配和释放内存怎么做?

思考一个问题,在C语言里,我们分配内存:

分配内存的时候,传入大小,拿到一个指针。

ptr = malloc(1024);

释放内存的时候,直接传入ptr,没有任何其他参数:

free (ptr);

释放的时候,怎么确定释放哪些位置?如果要你自己实现,有很多简单的思路,说一个最简单的:分配的时候,不止分配1024字节,还分配了其他的信息,带head了。



这种分配方式有什么问题:

  1. 开销大,在通用的内存分配器中,很多场景下,有可能meta信息比自身还要大。

5.2 内存分配设计考虑的几个问题

  1. 性能
    1. 局部性
  2. 碎片率
    1. 内部碎片率
    2. 外部碎片率

5.3 golang的内存分配

golang大方向的考虑就是基于局部性和碎片率来考虑的。使用的是和tcmalloc一致的设计。

5.3.1 整体设计

首先,内存块是不带类型信息的。像我们在C语言里面,有时候实现的简单的内存池,在不考虑一些开销的时候,会把业务类型放到meta信息里,为的是排查问题方便。golang内存管理作为一个通用模块,不会这么搞。

5.3.1.1 地址空间设计

很多时候,你查golang的资料,会看到这张图:



这张图有几个信息比较重要

  1. 为什么spans区域是512M,bitmap区是16G,arena是512G?先不要纠结值,我们先说这个比例关系:
    1. spans区域,一个指针大小(8Byte)对应arena的一个page(8KB),倍数是1024
    2. bitmap区域,一个字节(8bit)对应arena的32Bytes,倍数是32倍
  2. 我们给用户分配的内存就是arena区域的内存,spans区,bitmap区均为其他用途的元数据信息。
    1. bitmap这个实现我们这次不谈,不同通过这个你得知道一点:并不是所有的内存空间都会扫描一把,是有挑选判断的。
    2. spans区域是一般用来根据一个内存地址查询mspan结构的。调用函数:spanOf。
    3. bitmap是用来辅助垃圾回收用的区域。有这个bitmap信息可以提高回收效率和精度。注意一点,这个不是标识object是否分配的位图,标识是否分配object的问题是mspan.allocBits结构。这个可以理解为提高垃圾回收效率的实现。

注意几个点:

  1. 很多文章都提到golang内存512GB这个事情。512GB说的是内存虚拟地址空间的限制,是最大能力,是最大的规划利用。golang之前最大可以使用的内存地址空间。
  2. golang1.11 之后已经没有512GB的限制了。基本上和系统的虚拟地址空间一致
    1. 这个比例还是一样的,1:1024,1:32
  3. 就算golang1.11之前,也不是说golang的程序上来就向系统申请这么大块虚拟地址。也是每64M的申请,管理对象单元是heapArea结构。
  4. 三个区域看着连续结在一起,但是其实不是连续的地址。
    1. 实际的实现中都是以64M(heapArena)的小单位进行的。

5.3.2 抽象对象概念



物理偏向概念

  1. heapArena:堆上物理空间管理的一个小单元,64M一个。
  2. page:物理内存最小单位,8KB一个。

逻辑偏向概念

  1. span:span为内存分配的一个管理单元。span内按照固定大小size划分,相同的size划分为同一类。一个span管理一个连续的page。
  2. object:内存分配的最小单元。

管理结构层次概念

mcache:每个M上的,管理内存用的。我们都知道GMP架构,每个M都有自己的内存cache管理,这样是为了局部性。只是一个cache管理。mcentral:mheap结构所有,也只是一个cache管理,但是是为所有人服务的。mheap:是真正负责分配和释放物理内存的。

5.3.3 局部性的设计

这个思路很简单,就是设计成局部性的一个层次设计。

5.3.3.1 mcache

mcache由于只归属自己的M,span一旦在这个结构管理下,其他人是不可见,不会去操作的。只有这个m会操作。所以自然就不需要加锁。

5.3.3.2 mcentral

mcentral是所有人可见的。所以操作自然要互斥,这个的作用也是一个cache的统一管理。

5.3.3.3 mheap

这个是负责真实内存分配和释放的的一个结构。

5.3.4 针对碎片率的设计

golang的内存设计目标:碎片率平均12.5%左右。



说明:

  1. tail wast实际是浪费的外部碎片
    1. 比如说,第一种size,8字节。一个page 8KB,8字节刚好对齐。外部碎片为0.
  2. max waste说的是最大的内部碎片率
    1. 怎么算的?每一个放进该span的对象大小都是最小值的情况
    2. 比如说,第一种size,8字节。最小的对象是1字节,浪费7字节,最大碎片率为 1-1/8 = 87.5%

怎么的出来的这些值?经验值吧,可能。

6. 内存回收

6.1 golang协程抢占执行

首先,golang没有真正的抢占。golang调度单位为协程,所谓抢占,也就是强行剥夺执行权。但是有一点,golang本质上是非抢占的,不像操作系统那样,有时钟中断和时间片的概念。golang虽然里面是有一个抢占的概念,但是注意了,这个抢占是建议性质的抢占,也就是说,如果有协程不听话,那是没有办法的,实现抢占的效果是要对方协程自己配合的。

一句话:系统想让某个goroutine自己放弃执行权,会给这个协程设置一个魔数,协程在切调度,或者其他时机检查到了的时候,会感知到这一个行为。

当前的抢占实现是:

  1. 给这个协程设置一个的魔数(stackguard)。每个函数的入口会比较当前栈寄存器值和stackguard值来决定是否触发morestack函数。(这是一个抢占调度点)
  2. 协程调用函数的时候,会检查是否需要栈扩容。如果被设置了抢占标示,那么就会首先调用到
  3. 调用newstack,在newstack里面判断是否是特殊值,这种特殊值,目的不在于扩容,而在于让出调度。

所以,在golang里面,只要有函数调用,就会有感知抢占的时机。stw就是基于这个实现的。

思考一个问题:

如果有一个猥琐的函数:非常耗时,一直在做cpu操作,并且完全没有函数调用。这种情况下,golang是没有一点办法的。那么这种情况会影响到整个程序的能力。

所以,我们平时写函数,一定要短小精悍,功能拆分合理。

6.2 STW是怎么回事?

STW:stop the world,也就是说暂停说由协程的调度和执行。stw是怎么实现?stw的基础就是上面提到的抢占实现。stw调用的目的是为了让整个程序(赋值器停止),那么就需要剥夺每一个协程的执行。

stw在垃圾回收的几个关键操作里是需要的,比如开启垃圾回收,需要stw,做好准备工作。如果stw的时候,出现了猥琐的函数,那么会导致整个系统的能力降低。因为大家都在等你一个人。

6.3 垃圾回收要求

  1. 正确性:绝对不能回收正在使用的的内存对象。
  2. 存活性:一轮回收过程一定是有边界,可结束的。

6.4 golang版本迭代历史

  1. go 1.3 以前,使用是标记-清扫的方式,整个过程需要stw
  2. go 1.3 版本分离了标记和清扫操作,标记过程stw,清扫过程并发执行
  3. go 1.5 版本在标记过程中,使用三色标记法。回收过程分为四个阶段,其中,标记和清扫都并发执行的,但标记阶段的前后需要stw一定时间来做gc的准备工作和栈的re-scan。
  4. go 1.8 版本引入了混合写屏障机制,避免了对栈的re-scan,极大的减少了stw的时间。

6.5 GC触发条件

  • gcTriggerHeap 当分配的内存达到一定值就触发GC
  • gcTriggerTime 当一定时间没有执行过GC就触发
  • gcTriggerCycle 要求启动新一轮的GC,一启动则跳过,手动触发GC的runtime.GC( )会使用这个条件

6.6 三色定义

6.6.1 强三色

黑色对象不允许指向白色对象。

6.6.2 弱三色

黑色对象可以指向白色对象,但是前提是,该白色对象一定是处于灰色保护链中。

6.7 GC流程

这里不详细阐述了。贴一张go1.8之前的图:



当下GC大概分为四个阶段:

  1. GC准备阶段
  2. 标记阶段
  3. 标记结束阶段
  4. 清理阶段

6.8 写屏障

如果标记和回收不用和应用程序并发,在标记和回收整个过程直接stw,那么就简单了。golang为了提供低时延,就必须让赋值器和回收器并发起来。但是在并发的过程中,赋值器和回收器对于引用树的理解就会出现不一致,这里就一定要配合写屏障技术。

写屏障技术,是动态捕捉写操作,维持回收正确性的技术。写屏障就是一段 hook 代码,编译期间生成,运行期间跟进情况会调用到 hook 的代码段,也就是写屏障的代码;

下面系统整体的讨论下写屏障的技术。

6.8.1 插入写屏障

(Dijkstra '78)

writePointer ( slot, ptr ): // 无脑保护插入的新值 shade ( ptr ) *slot = ptr

这个是另外一个通用的屏障技术。这个维护的是强三色不变式来保证正确性,保证黑色对象一定不能指向白色对象。golang使用的是这个屏障,插入屏障。按照道理,是几乎完全不需要stw的。但是golang有一个处理,由于栈上面使用屏障会导致处理非常复杂,并且开销会非常大。所以当前golang只针对堆上的写操作做了屏障。

那么就会带来一个问题:所以当一轮扫描完了之后,在标记结束的阶段,还需要重新扫描一遍goroutine栈,并且栈引用到的所有对象也要扫描。因为goroutine有可能直接指向了白色对象。在扫描goroutine栈过程中,需要stw。这个也是go1.8以前的一个非常大的延迟来源。

(开始的时候,stw扫描栈,得到灰色对象)

图表演示

堆上路径赋值:

step1:堆上对象赋值的时候,插入写屏障,保护强三色不变式

step2:删除的时候,没啥问题

栈上对象赋值:

step3:栈上对象赋值的时候,没有写屏障。白色对象直接被黑色对象引用。

step4:删除灰色保护路径。

所以才需要在mark terminato阶段,重新扫描栈。

6.8.2 删除写屏障

(Yuasa '90)

writePointer ( slot, ptr ): // 删除之前,保护原先白色或者灰色指向的数据块 if ( isGery ( slot ) || isWhite ( slot ) ) shade ( *slot ) *slot = ptr

这个是通用的一种写屏障技术。golang并没有实现,而是实现了插入写屏障。原因就在于:这个在垃圾回收之前,必须做一个快照扫描,这个就会对用户时延有比较严重的影响。下面详述。

主要流程:

  1. 在标记之前,需要打一个引用关系的快照。所以,这个对于栈内存很大的时候,影响越大。
    1. 不需要完整的快照,只需要在扫描堆对象之前,确保所有的栈对象是黑色的。引用都是灰色的,这样就保证了一个前提:所有可达的对象都处于灰色保护状态中。
    2. 对栈快照扫描需要stw,去扫描栈对象。这个时候,是需要暂停所有的用户程序。
  2. 扫描堆对象的时候,可以和应用程序并发的。此后根一直保持黑色(黑色赋值器),不用再扫描栈。
  3. 对象被删除的时候,删除写屏障会捕捉到。置灰。
    1. 上面的伪代码显示有条件,其实第一版的时候是没有条件的。
    2. 这里加上条件是为了回收精度:当上游之前是白色或者灰色才需要把这个置灰色。如果是黑?那么一定是处于灰色保护状态,因为这个是前提(理解这个非常重要)。

(开始的时候,stw扫描栈,得到灰色对象)

图表演示

初始扫描快照后:

step1: 赋值。这里赋值是允许的,虽然是破坏了强三色不变式。但是还是符合弱三色不变式。

step2:删除。这里就拦截了,必须置灰色。保证弱三色不变式。

回收精度:

删除写屏障的精度比插入写屏障的精度更低。删除的即使是最后一个指针,也会保留到下一轮,属于一个浮动垃圾。这个比插入屏障精度还低。因为,对于插入屏障所保留的对象,回收器至少可以确定曾在其中执行了某些回收相关的操作(获取或写入对象的引用),但删除屏障所保留的对象却不一定被赋值器操作过。

为什么需要打快照?

删除写屏障,又叫快照屏障增量技术(或者说,一定要配合这个来做)。

  1. 首先,是需要stw,针对扫描整个栈根打做一遍扫描。相当于一个快照。这个过程扫描之后,就能保证当前(时刻)所有可达的对象都处于灰色保护状态,满足弱三色不变式。
  2. 然后,赋值器和回收器就可以并发。但是并发有可能会破坏导致弱三色不变式。这个时候,就需要删除写屏障来时刻保护白色对象。

golang为啥没有用这个?

  1. 一个是精度问题,这个精度要比插入写屏障低;
  2. 考虑goroutine可能非常多,不适合上来就stw,扫描所有的内存栈。这个适合小内存的场景。
    1. 思考一个问题:这个和混合写屏障有没有区别?还是有区别的,这里是要锁整个栈,混合写屏障是并发的,每次只需要锁单个栈。

6.8.3 混合写屏障

混合屏障是结合插入屏障和删除屏障。

伪代码:

writePointer (slot, ptr) : // 保护原来的(被删除的) shade ( *slot ) if current stack is grey: // 如果对象为灰色,则还需要保护新指向的对象 shade ( ptr ) *slot = ptr

(开始的时候,stw扫描栈,得到黑色对象)

golang实际情况:

伪代码如上。但是这里提出来一点,golang根本不是和伪代码说的这样。没有做条件判断,所以现在的回收精度很低。这个算是一个TodoList。



注意:使用了混合屏障,还是针对堆上的,栈上对象写入还是没有barrier。golang之前只使用插入屏障,关键在于栈对象没有,导致栈上黑对象可能指向白对象。所以要rescan。因为如果不rescan,而且又破坏了弱三色不变式(没有处于灰色保护链中),那么就丢数据了。

混合屏障,就是结合删除屏障,保护这一个前提,代价就是进一步降低回收精度。

图表示例:

混合屏障就是要解决:栈指向白色对象,stw重新扫描栈的问题。

step1:赋值白对象到黑对象引用,这个不会阻止这个,也不会有写屏障。就是一个正常的赋值。

  1. 这个时候黑色指向了白色对象。破坏了强三色不变式。
  2. 但是这个白色对象还处于灰色状态保护下。符合弱三色不变式。


step2:删除指针的时候,意图破坏弱三色不变式的时候,写屏障就会把这个对象置灰色。



问题一:如果有个还会想?由于栈上没有写屏障,这个删除的对象式根指向的呢?如果存在以下场景?

step1:堆上的白色对象引用赋值给黑色栈对象。

step2:如果删除指针,岂不是连弱三色不变式也破坏了?

这个怎么办呢?

答案是:其实根本就不可能出现这个场景的引用图。第一个图就不会出现。因为虽然没有stw,但是扫描某个g的时候,这个g是暂停的。相当于这个g栈是一个快照状态。

混合写屏障的栈,要么全黑,要么全白(单个栈)

那么这个暂停g这个是怎么做到的?

  1. 扫描的时候,会设置一个 _Gscan 状态。
  2. casgstatus的时候,保证循环等待这个状态完成。之前是直接吃cpu的,后面做了一个优化,加了一个yield,5us的间隔。
    1. 关于这段代码的改动

问题二:如果是多个栈呢,那么就不是原子的快照了。比如下图?那么就可能导致这种情况。



如果说A和前面的黑色对象不属于同一个g栈。那么是否可能会导致这种场景出现?分析下:

  1. 这个场景是有这么一个白色对象,先只被G2栈根引用到。
  2. 当前G1已经被扫描完,G2还没有扫描。
  3. 把这个白色对象赋值给G1栈的黑色对象。
  4. 这个时候把G2对白色对象的引用删掉,这样岂不是会出现黑色白色对象,且为唯一指针?

答案是:这里的关键在于第三步。G1的栈对象接受赋值,这个并不是凭空来的。那么一定是G1自己找来的,可达的对象。这个是一个前提。所以,如果能接受这样的赋值,那么这个白色对象一定是处于G1栈的灰色保护下,因为G1一定是可访问这个对象的。否则,根本就不能完成这个赋值。

混合写屏障的场景,白色对象处于灰色保护下,但是只由堆上的灰色对象保护。注意理解这点;

屏障生成示例:



  1. 写堆上内容,才会在编译期间生成写屏障
  2. 栈上的写,不会有写屏障。

runtime.gcWriteBarrier :



  1. 计算出wbBuf的next位置
  2. record ptr
    1. ptr指针放到wbBuf队列中。
  3. *(slot) 存到wbBuf队列中 ( 置灰色,flush了就是灰色 )
    1. shade( *slot )
  4. 如果队列没有满
    1. 那么就赋值写(*(slot) = ptr); 则返回
  5. 如果队列满了,那么跳到flush
    1. wbBufFlush就是把wbBufFlush里的元属flush到灰色队列中。
    2. 调用完了 runtime.wbBufFlush 处理之后,返回赋值ret(*(slot) = ptr

这么看起来,就不存在 判断stack是否为灰色的条件?

6.8.4 其他屏障

writePointer(slot, ptr): shade(*slot) shade(ptr) *slot = ptr

优点:

  1. 这种无条件的屏障更加容易理解,直接把目标和源都置灰色保护
  2. heap上没有黑色到白色的指针
  3. 唯一有可能出现黑色到白色的引用 只可能出现在 被扫描了的stack
  4. 一旦 stack 被扫描过了,只有一种办法能得到白色对象指针(white pointer):通过transfer一个可达(reachable)对象
    1. 删除屏障和混合写屏障,保护了shade(*slot)这个指针,就保护了一条路径:这个来路一定是灰色的,下游的白色都会收到保护。并且,我们知道,栈上得到的白色指针一定是可达的,那么一定是有堆上灰色对象保护的。
  5. 任何一个白色对象(被黑色栈对象指向的)一定是被堆上灰色对象保护可达的。

缺点:

这种屏障会导致比较多的屏障,两倍。所以针对这个考虑权衡,会加一个stack条件判断,就是我们看到的混合屏障的样子。

6.9 内存可见性

提一下golang的内存可见性。在c里面,如果是在多线程环境,并发操作一些变量,需要考虑一些可见性的问题。比如赋值一个变量,这个线程还有可能在寄存器里没有刷下去,或者编译器帮你优化到寄存器中,不去内存读。所以有一个volatile关键字,强制去内存读。

golang是否有这个内存可见性的问题?

一句话,golang里面,只要你保证顺序性,那么内存一致性就没有问题。具体可以搜索happen-before的机制。

6.10 注意问题

6.10.1 千万不要尝试绕过golang的类型系统

千万不要尝试绕过golang的类型系统。golang官方在提到uintptr类型的时候,都说不要产生uintptr的临时变量,因为很有可能会导致gc的错误回收(这个做过一个简单的验证,1.13本的uintptr类型是不作为指针标记的)。

举一个极端的例子,如果你new了一个对象,然后把这个对象的地址保存在8个不连续的byte类型里,那就等着coredump吧。

6.10.2 在golang里按照c的思路实现一个内存池很容易踩到巨坑。

比如现在你分配一个大内存出来(1G的[ ]byte类型空间)。这是一个大内存块。并且golang没有任何标识这个地方标识指针。

// 分配一个大内存数组(1GB),数组元素是byte。那么自然每个元素都是不含指针的。begin := make([]byte, 1024*1024*1024)


那么扫描是不会扫描这个内部的。

内存池分配器接口:func (ac *Allocator) Alloc (size int) unsafe.Pointer

用来分配对象,使用可能会导致莫名其妙的内存错误。假设用来分配对象T:

type T struct { s *S}t := (*T) (ac.Alloc(sizeT))t.s = &S{}

T对象是从一个大数组里划出来的,垃圾回收其实并不知道T这个对象。不过只要1G内存池本身不被回收,T对象还是安全的。但是T里面的S,是golang走类型系统分配出来的,就会有问题。

假设发生垃圾回收了,GC会认为这个内存空间是一个Byte数组,而不会扫描,那么t.s指向的对象认为未被任何对象引用到,它会被清理掉。最后t.s就成了一个悬挂指针。

golang里面实现内存分配器,适用处理两种情况:

  1. 一种是用于分配对象里面不包含其他引用
  2. 另一种,包含的引用对象也在这个分配器里

其实,没必要自己搞通用内存池。一旦绕过了golang的类型系统,就会出现坑。




坚持思考,方向比努力更重要。关注我:奇伢云存储





您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存